<?php
/**
 * CFileCache class file
 *
 * @author Qiang Xue <qiang.xue@gmail.com>
 * @link http://www.yiiframework.com/
 * @copyright 2008-2013 Yii Software LLC
 * @license http://www.yiiframework.com/license/
 */

/**
 * CFileCache provides a file-based caching mechanism.
 *
 * For each data value being cached, CFileCache will use store it in a separate file
 * under {@link cachePath} which defaults to 'protected/runtime/cache'.
 * CFileCache will perform garbage collection automatically to remove expired cache files.
 *
 * See {@link CCache} manual for common cache operations that are supported by CFileCache.
 *
 * @property integer $gCProbability The probability (parts per million) that garbage collection (GC) should be performed
 * when storing a piece of data in the cache. Defaults to 100, meaning 0.01% chance.
 *
 * @author Qiang Xue <qiang.xue@gmail.com>
 * @package system.caching
 */
class CFileCache extends CCache
{
    /**
     * @var string the directory to store cache files. Defaults to null, meaning
     * using 'protected/runtime/cache' as the directory.
     */
    public $cachePath;
    /**
     * @var string cache file suffix. Defaults to '.bin'.
     */
    public $cacheFileSuffix = '.bin';
    /**
     * @var integer the level of sub-directories to store cache files. Defaults to 0,
     * meaning no sub-directories. If the system has huge number of cache files (e.g. 10K+),
     * you may want to set this value to be 1 or 2 so that the file system is not over burdened.
     * The value of this property should not exceed 16 (less than 3 is recommended).
     */
    public $directoryLevel = 0;
    /**
     * @var boolean whether cache entry expiration time should be embedded into a physical file.
     * Defaults to false meaning that the file modification time will be used to store expire value.
     * True value means that first ten bytes of the file would be reserved and used to store expiration time.
     * On some systems PHP is not allowed to change file modification time to be in future even with 777
     * permissions, so this property could be useful in this case.
     * @since 1.1.14
     */
    public $embedExpiry = false;

    private $_gcProbability = 100;
    private $_gced = false;

    /**
     * Initializes this application component.
     * This method is required by the {@link IApplicationComponent} interface.
     */
    public function init()
    {
        parent::init();
        if ($this->cachePath === null)
            $this->cachePath = Yii::app()->getRuntimePath() . DIRECTORY_SEPARATOR . 'cache';
        if (!is_dir($this->cachePath))
            mkdir($this->cachePath, 0777, true);
    }

    /**
     * @return integer the probability (parts per million) that garbage collection (GC) should be performed
     * when storing a piece of data in the cache. Defaults to 100, meaning 0.01% chance.
     */
    public function getGCProbability()
    {
        return $this->_gcProbability;
    }

    /**
     * @param integer $value the probability (parts per million) that garbage collection (GC) should be performed
     * when storing a piece of data in the cache. Defaults to 100, meaning 0.01% chance.
     * This number should be between 0 and 1000000. A value 0 meaning no GC will be performed at all.
     */
    public function setGCProbability($value)
    {
        $value = (int)$value;
        if ($value < 0)
            $value = 0;
        if ($value > 1000000)
            $value = 1000000;
        $this->_gcProbability = $value;
    }

    /**
     * Deletes all values from cache.
     * This is the implementation of the method declared in the parent class.
     * @return boolean whether the flush operation was successful.
     * @since 1.1.5
     */
    protected function flushValues()
    {
        $this->gc(false);
        return true;
    }

    /**
     * Retrieves a value from cache with a specified key.
     * This is the implementation of the method declared in the parent class.
     * @param string $key a unique key identifying the cached value
     * @return string|boolean the value stored in cache, false if the value is not in the cache or expired.
     */
    protected function getValue($key)
    {
        $cacheFile = $this->getCacheFile($key);
        if (($time = $this->filemtime($cacheFile)) > time())
            return @file_get_contents($cacheFile, false, null, $this->embedExpiry ? 10 : -1);
        elseif ($time > 0)
            @unlink($cacheFile);
        return false;
    }

    /**
     * Stores a value identified by a key in cache.
     * This is the implementation of the method declared in the parent class.
     *
     * @param string $key the key identifying the value to be cached
     * @param string $value the value to be cached
     * @param integer $expire the number of seconds in which the cached value will expire. 0 means never expire.
     * @return boolean true if the value is successfully stored into cache, false otherwise
     */
    protected function setValue($key, $value, $expire)
    {
        if (!$this->_gced && mt_rand(0, 1000000) < $this->_gcProbability) {
            $this->gc();
            $this->_gced = true;
        }

        if ($expire <= 0)
            $expire = 31536000; // 1 year
        $expire += time();

        $cacheFile = $this->getCacheFile($key);
        if ($this->directoryLevel > 0)
            @mkdir(dirname($cacheFile), 0777, true);
        if (@file_put_contents($cacheFile, $this->embedExpiry ? $expire . $value : $value, LOCK_EX) !== false) {
            @chmod($cacheFile, 0777);
            return $this->embedExpiry ? true : @touch($cacheFile, $expire);
        } else
            return false;
    }

    /**
     * Stores a value identified by a key into cache if the cache does not contain this key.
     * This is the implementation of the method declared in the parent class.
     *
     * @param string $key the key identifying the value to be cached
     * @param string $value the value to be cached
     * @param integer $expire the number of seconds in which the cached value will expire. 0 means never expire.
     * @return boolean true if the value is successfully stored into cache, false otherwise
     */
    protected function addValue($key, $value, $expire)
    {
        $cacheFile = $this->getCacheFile($key);
        if ($this->filemtime($cacheFile) > time())
            return false;
        return $this->setValue($key, $value, $expire);
    }

    /**
     * Deletes a value with the specified key from cache
     * This is the implementation of the method declared in the parent class.
     * @param string $key the key of the value to be deleted
     * @return boolean if no error happens during deletion
     */
    protected function deleteValue($key)
    {
        $cacheFile = $this->getCacheFile($key);
        return @unlink($cacheFile);
    }

    /**
     * Returns the cache file path given the cache key.
     * @param string $key cache key
     * @return string the cache file path
     */
    protected function getCacheFile($key)
    {
        if ($this->directoryLevel > 0) {
            $base = $this->cachePath;
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
                if (($prefix = substr($key, $i + $i, 2)) !== false)
                    $base .= DIRECTORY_SEPARATOR . $prefix;
            }
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
        } else
            return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
    }

    /**
     * Removes expired cache files.
     * @param boolean $expiredOnly whether only expired cache files should be removed.
     * If false, all cache files under {@link cachePath} will be removed.
     * @param string $path the path to clean with. If null, it will be {@link cachePath}.
     */
    public function gc($expiredOnly = true, $path = null)
    {
        if ($path === null)
            $path = $this->cachePath;
        if (($handle = opendir($path)) === false)
            return;
        while (($file = readdir($handle)) !== false) {
            if ($file[0] === '.')
                continue;
            $fullPath = $path . DIRECTORY_SEPARATOR . $file;
            if (is_dir($fullPath))
                $this->gc($expiredOnly, $fullPath);
            elseif ($expiredOnly && $this->filemtime($fullPath) < time() || !$expiredOnly)
                @unlink($fullPath);
        }
        closedir($handle);
    }

    /**
     * Returns cache file modification time. {@link $embedExpiry} aware.
     * @param string $path to the file, modification time to be retrieved from.
     * @return integer file modification time.
     */
    private function filemtime($path)
    {
        if ($this->embedExpiry)
            return (int)@file_get_contents($path, false, null, 0, 10);
        else
            return @filemtime($path);
    }
}
